Skip to content

feat: add AgentAsTool#1932

Open
notowen333 wants to merge 8 commits intostrands-agents:mainfrom
notowen333:agent-as-tool
Open

feat: add AgentAsTool#1932
notowen333 wants to merge 8 commits intostrands-agents:mainfrom
notowen333:agent-as-tool

Conversation

@notowen333
Copy link

@notowen333 notowen333 commented Mar 18, 2026

Description

Will close #1002.

This PR adds the AgentAsTool class as well as a convenience method on the Agent class to return an instance of it.

The AgentAsTool class just calls stream_async on the underlying agent and formats the response as a ToolResultEvent.

The added integration test confirms that an AgentAsTool was invoked and that agent-as-tool used a tool of its own.

Related Issues

#1002

Documentation PR

strands-agents/docs#686

Type of Change

New feature

Testing

  • Added unit tests

  • Added a minimal integ test

  • [ x] I ran hatch run prepare

Checklist

  • [ x ] I have read the CONTRIBUTING document
  • [ x ] I have added any necessary tests that prove my fix is effective or my feature works
  • [ x ] I have updated the documentation accordingly
  • [ x ] I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • [ x ] My changes generate no new warnings
  • [ x ] Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@codecov
Copy link

codecov bot commented Mar 18, 2026

Codecov Report

❌ Patch coverage is 97.16981% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/strands/agent/_agent_as_tool.py 96.70% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

writer("Write about AI agents")
```
"""
if not name:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Empty string check uses falsy evaluation which is inconsistent.

Suggestion: Consider using explicit is None checks for clarity. Currently if not name would treat an empty string "" the same as None, which may not be the intended behavior:

if name is None:
    name = self.name
if description is None:
    description = self.description or f"Use the {name} tool to invoke this agent as a tool"

This makes the API contract clearer - None means "use default" while an explicit empty string could be considered user error.

Yields:
ToolStreamEvent for intermediate events, then ToolResultEvent with the final response.
"""
prompt = tool_use["input"].get("input", "") if isinstance(tool_use["input"], dict) else tool_use["input"]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The input extraction logic handles two cases but could silently fail with unexpected input formats.

Suggestion: Consider adding explicit type handling or validation:

tool_input = tool_use["input"]
if isinstance(tool_input, dict):
    prompt = tool_input.get("input", "")
elif isinstance(tool_input, str):
    prompt = tool_input
else:
    logger.warning("tool_name=<%s> | unexpected input type: %s", self._tool_name, type(tool_input))
    prompt = str(tool_input)

@github-actions
Copy link

Issue: The original issue #1002 mentions a preserve_context: bool parameter to control whether previous conversation context is preserved between invocations.

Question: Is this parameter intentionally deferred to a follow-up PR, or should it be included in this initial implementation? The current implementation appears to preserve context by default (since it uses the same agent instance), but there's no way for users to reset context between calls.

This could be a breaking change if added later, so it would be good to clarify the intended behavior.

@github-actions
Copy link

Code Review Summary

Assessment: Request Changes

This PR introduces a well-designed AgentAsTool class and Agent.as_tool() convenience method. The implementation follows SDK patterns and includes comprehensive tests. However, documentation is required before merge.

Review Categories
  • Documentation: Missing documentation PR. This introduces new public API surface (AgentAsTool class, Agent.as_tool() method) and requires user documentation per project guidelines.
  • API Review: Consider adding needs-api-review label since this adds new public abstractions customers will use.
  • Design Clarification: The preserve_context parameter from [FEATURE] Agent to_tool method #1002 should be addressed - either as part of this PR or explicitly deferred.

The core implementation looks solid - just needs the documentation PR linked to unblock merge.

@notowen333
Copy link
Author

/strands review

@github-actions
Copy link

Code Review Summary - Updated

Assessment: Approve ✅

Thank you for addressing the feedback from the previous review! All blocking issues have been resolved:

Changes Addressed
  • Documentation PR: Now linked (feat: add section in user guide on new functionality for Agent.as_tool() docs#686)
  • preserve_context parameter: Implemented with proper input schema, context clearing logic, and comprehensive tests
  • Input type handling: Added explicit type checks with warning logs for unexpected input formats
  • Test coverage: Expanded from 213 to 352 lines with tests for all new functionality
Minor Notes (Non-blocking)
  • The discussion with @awsarron about class vs @tool approach is still open - waiting for direction on how to proceed
  • Coverage at 94.52% is acceptable; the 4 uncovered lines appear to be edge cases
  • Previous inline comments about empty string checks remain as suggestions but are not blocking

The implementation is well-designed, follows SDK patterns, and includes proper error handling and logging. Ready to merge once the awsarron discussion is resolved and any CI checks pass.

"type": "string",
"description": "The input to send to the agent tool.",
},
"preserve_context": {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a class level field. I think most consumers want to decide at creation time whether it's a persistent tool (preserve_context=True) or new agent call on every invocation (preserve_context=False).

I also wonder if there's a way we can determine the best default - if not, maybe make it required

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swapped to class level field with default True. I think we should take the least destructive default

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our docs implementation seems to correspond to preserve_context=False: https://strandsagents.com/docs/user-guide/concepts/multi-agent/agents-as-tools/

I just realized, preserve_context=True might need some sort of integration with the parent agent's AgentState, as otherwise if an agent is persisted & rehydrated the context of the sub-agent isn't restored. Maybe that's alright, as it's a tool concern, bug given that it's a tool that we're vending, we should at least explore if an agent-as-tool should integrate with the parent's AgentState

self._tool_name,
tool_use_id,
)
messages.clear()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want to clear the agent in this case; we want to restore it to the original state that it was at. The graph node does something similar I think.

In the future, we can use snapshots to simplify this.

AgentState is another one that will need to be restored

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

took the pattern from graph.py

Comment on lines +56 to +67
def test_init_sets_name(mock_agent):
tool = AgentAsTool(mock_agent, name="my_tool", description="desc")
assert tool.tool_name == "my_tool"


def test_init_sets_description(mock_agent):
tool = AgentAsTool(mock_agent, name="my_tool", description="custom desc")
assert tool._description == "custom desc"


def test_init_stores_agent_reference(mock_agent, tool):
assert tool.agent is mock_agent
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Combine this into one test

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines +73 to +96
def test_tool_name(tool):
assert tool.tool_name == "test_agent"


def test_tool_type(tool):
assert tool.tool_type == "agent"


def test_tool_spec_name(tool):
assert tool.tool_spec["name"] == "test_agent"


def test_tool_spec_description(tool):
assert tool.tool_spec["description"] == "A test agent"


def test_tool_spec_input_schema(tool):
schema = tool.tool_spec["inputSchema"]["json"]
assert schema["type"] == "object"
assert "input" in schema["properties"]
assert schema["properties"]["input"]["type"] == "string"
assert "preserve_context" in schema["properties"]
assert schema["properties"]["preserve_context"]["type"] == "boolean"
assert schema["required"] == ["input"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do an assert on the entire spec at once

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines +100 to +102
props = tool.get_display_properties()
assert props["Agent"] == "test_agent"
assert props["Type"] == "agent"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here - assert on the entire object

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

if "result" in event:
result = event["result"]
else:
yield event
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will auto wrap this in a ToolStreamResult, but I think we actually want to do additional wrapping here - Maybe an AgentAsToolStreamEvent?

If we don't, the caller cannot tell the difference between "This is ToolStreamEvent that came from an tool wrapping an agent using a tool" vs "This is a ToolStreamEvent from a tool"

AgentAsToolStreamEvent would probably reference the AgentAsTool class

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added AgentAsToolStreamEvent class that is a subclass of ToolStreamEvent to differentiate

if not name:
name = self.name
if not description:
description = self.description or f"Use the {name} tool to invoke this agent as a tool"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need a better description I think

invoke this agent as a tool

Doesn't really make sense to an agent. It's going to see a tool that says:

name: "researcher"
description: "Use the researcher tool to invoke this agent as a tool"

where this doesn't make sense.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

swapped to Use the {name} agent as a tool by providing a natural language input


tool_use_id = tool_use["toolUseId"]

if not preserve_context:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should throw if preserve_context=False and _agent != Agent; that would indicate A2A agent which for preserve_context=False doesn't make sense

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put this check in the constructor

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's rename this as _agent_as_tool.py and just export at the top level

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed

…e; yield AgentAsToolStreamEvents; small fixes
@notowen333
Copy link
Author

/strands review

@mkmeral
Copy link
Contributor

mkmeral commented Mar 25, 2026

/strands review if there are any issues with the last logic changes (+ general review)

@github-actions
Copy link

Code Review Summary - Round 4 (Logic Changes Focus)

Assessment: Approve ✅

Reviewed the latest logic changes including concurrency handling, interrupt propagation, and default value changes. The implementation is solid.

Key Logic Changes Reviewed
Change Analysis
Default preserve_context=False ✅ Aligns with docs, safer default (stateless). Note: non-Agent instances now raise TypeError by default
Threading lock ✅ Uses threading.Lock for cross-thread safety, non-blocking acquire, properly released in finally
Interrupt propagation ✅ Detects interrupted state via getattr, builds responses, propagates via ToolInterruptEvent
Resume logic ✅ Correctly skips state reset when resuming from interrupt
Executor registration ✅ Uses setdefault to register sub-agent interrupts without overwriting
Concurrency Handling

The lock implementation is correct:

  • Non-blocking acquire (line 168): Returns error if agent is busy instead of blocking
  • Early return safety: If lock acquire fails, returns before holding the lock (no leak)
  • Finally block (line 250-251): Ensures lock is always released, even on exceptions
  • Threading.Lock choice: Correct for run_async() which may use separate threads/event loops
Interrupt Flow
  1. Sub-agent hits interrupt → returns AgentResult with stop_reason="interrupt"
  2. AgentAsTool.stream() yields ToolInterruptEvent (line 216)
  3. Executor registers interrupts in parent's _interrupt_state (executor.py:233-234)
  4. On resume: _is_sub_agent_interrupted() detects activated state
  5. _build_interrupt_responses() packages responses (filtering None values)
  6. Sub-agent receives responses instead of original prompt

No issues found with the logic changes. The implementation handles edge cases correctly and includes comprehensive test coverage (672 lines covering concurrency, interrupts, state management).

@agent-of-mkmeral
Copy link

Decision Log: preserve_context × Session Managers × AgentBase Default

Researched and tested the interaction between preserve_context, session managers, and the default behavior for non-Agent types. 13 targeted tests, all passing.


🔴 Finding 1: preserve_context=False + Session Manager = State Divergence (Bug)

The Problem

When a sub-agent has a session manager AND preserve_context=False (the default), the in-memory state and persisted state diverge after the first invocation:

sub_agent = Agent(session_manager=FileSessionManager(session_id="s1"))
tool = sub_agent.as_tool()  # preserve_context=False (default)
parent = Agent(tools=[tool])

What Happens

Invocation 1:

  1. _reset_agent_state(): agent.messages = deepcopy([]) (direct list assignment)
  2. stream_async() runs → _append_messages() fires MessageAddedEvent
  3. Session hooks fire: append_message(A, idx=0), append_message(B, idx=1), append_message(C, idx=2)
  4. AfterInvocationEventsync_agent() persists state
  5. Session file: [A(0), B(1), C(2)]
  6. In-memory: [A, B, C]

Invocation 2:

  1. _reset_agent_state(): agent.messages = deepcopy([]) — but session._latest_agent_message still points to C(idx=2)
  2. stream_async() runs → _append_messages() fires MessageAddedEvent
  3. Session hooks fire: append_message(D, idx=3), append_message(E, idx=4) — indices continue from previous
  4. Session file: [A(0), B(1), C(2), D(3), E(4)] — accumulating ❌
  5. In-memory: [D, E] — amnesiac, as intended ✅

Root Cause

_reset_agent_state() does a direct list assignment (self._agent.messages = copy.deepcopy(...)) which bypasses _append_messages() and therefore never fires MessageAddedEvent. The session manager:

  1. Never sees the reset happen
  2. Never prunes old messages
  3. Keeps its _latest_agent_message tracker pointing to the last message from the previous invocation
  4. Continues appending new messages at ever-increasing indices

Consequence

If the sub-agent is ever restored from session (e.g., process restart, rehydration), it gets ALL accumulated messages [A, B, C, D, E], defeating the amnesiac intention entirely.

Additional Nuance: "Amnesiac" Doesn't Mean "Empty"

The snapshot is taken after Agent.__init__() fires AgentInitializedEvent, which triggers the session manager's initialize() method. If there's a persisted session, messages are restored into agent.messages before the snapshot. So _initial_messages = whatever the session manager restored, not [].

preserve_context=False really means "reset to session-restored state", not "reset to empty".

Note

The GraphNode.reset_executor_state() pattern (which this mirrors) has the same potential issue — it's just less visible because graph nodes are typically short-lived and don't persist across process restarts.

🟡 Finding 2: No Race Condition, But Stale Session Tracker

Is There a Race Condition?

No, in the traditional sense. The threading.Lock in AgentAsTool.stream() ensures _reset_agent_state() + stream_async() happen atomically. A concurrent caller gets an immediate error.

But There IS a Stale State Issue

After _reset_agent_state():

  • agent.messages = []
  • agent.state = initial_state
  • session._latest_agent_message[agent_id] = SessionMessage(message_id=N) from previous invocation ❌ (not reset)
  • agent.conversation_manager state (e.g., removed_message_count) = stale from previous invocations ❌ (not reset)

The session manager's internal trackers are not aware of the agent reset. They continue operating as if the conversation is continuous.

_reset_agent_state only resets:

  • agent.messages
  • agent.state

_reset_agent_state does NOT reset:

  • session_manager._latest_agent_message
  • agent.conversation_manager state
  • agent._interrupt_state (but this is handled separately via _is_sub_agent_interrupted)
🟡 Finding 3: preserve_context=False Default Only Works for Agent

The Constraint

preserve_context defaults to False. But preserve_context=False requires an Agent instance (not AgentBase). This means:

Type as_tool() available? Default works? Must set preserve_context=True?
Agent ✅ Yes (method on Agent) ✅ Yes No
AgentBase subclass (A2A, custom) ❌ No (as_tool not on AgentBase) N/A Must construct AgentAsTool directly
AgentBase + AgentAsTool(...) directly N/A ❌ Raises TypeError ✅ Yes, mandatory

Is This Actually a Problem?

Not really. The as_tool() convenience method only exists on Agent, so the default preserve_context=False always has a valid target. A2A agents can't accidentally hit this because they'd have to go through AgentAsTool(...) directly, which raises a clear TypeError.

The error message is explicit:

TypeError: preserve_context=False requires an Agent instance, got MyCustomAgent

Why This Design?

AgentBase doesn't guarantee messages or state attributes exist. You can't snapshot/restore what doesn't exist. So the type check is correct — it's a capability check, not an arbitrary restriction.

📋 Test Results (13/13 passing)
TestPreserveContextWithSessionManager::test_reset_does_not_trigger_message_added_hooks PASSED
TestPreserveContextWithSessionManager::test_session_manager_initialize_runs_on_sub_agent_construction PASSED
TestPreserveContextWithSessionManager::test_session_message_index_diverges_after_reset PASSED
TestPreserveContextWithSessionManager::test_sync_agent_after_reset_persists_wrong_state PASSED
TestRaceConditions::test_reset_then_stream_is_not_atomic_with_session_hooks PASSED
TestRaceConditions::test_session_latest_message_not_reset PASSED
TestPreserveContextDefault::test_default_raises_for_agent_base PASSED
TestPreserveContextDefault::test_preserve_context_true_works_for_agent_base PASSED
TestPreserveContextDefault::test_default_means_a2a_agents_must_use_preserve_context_true PASSED
TestFundamentalIssue::test_document_the_divergence_scenario PASSED
TestFundamentalIssue::test_verify_session_hooks_registered_on_sub_agent PASSED
TestConversationManagerInteraction::test_conversation_manager_state_not_reset PASSED
TestConversationManagerInteraction::test_apply_management_runs_after_each_invocation PASSED

Tests verify code structure and behavior via source inspection + runtime type checking. No mocked model calls needed — these test the interaction patterns, not the end-to-end flow.

💡 Recommendations

For This PR

  1. Document the session manager interaction in the docstring — Add a note to preserve_context parameter docs that it does NOT coordinate with session managers. Developers who set both will get in-memory amnesiac but persistent accumulation.

  2. Consider a warning — When preserve_context=False and the wrapped agent has a _session_manager, log a warning:

    if not preserve_context and hasattr(agent, '_session_manager') and agent._session_manager:
        logger.warning("preserve_context=False with a session manager may cause state divergence")

For Future Work

  1. Unified state management — Both GraphNode.reset_executor_state() and AgentAsTool._reset_agent_state() have the same pattern and same gap. This should move to a first-class method on Agent (e.g., agent.reset_to_snapshot(snapshot)) that properly coordinates with session managers and conversation managers.

  2. Session-aware reset — The reset method should either:

    • Clear session messages and reset the _latest_agent_message tracker, OR
    • Detach the session manager during sub-agent tool invocations (the sub-agent's session is the parent's concern, not its own)

🤖 AI agent response. Strands Agents. Feedback welcome!

@mkmeral
Copy link
Contributor

mkmeral commented Mar 25, 2026

^ following up on the findings couple of things:

  1. BLOCKER IMO: I think default should be enabled for preserve context. Why? it's the default behavior for all agents. if people want to replicate the amnesiac method from docs, they can do so by enabling. Also the current default does not work with any AgentBase object except for Agent. I think that's not okay for scaling this later on to A2A and multiagent
  2. BLOCKER: The session manager bug that agent found. Probably easiest escape route for now is making sure if we are resetting state, the agent does not have session manager. we can throw error
  3. Should we allow AgentBase in tools list, and then normalize with defaults? Why do I need to do tools=[agent.as_tool()] instead of tools=[agent]?
  4. Should we add as_tool() to AgentBase? (i guess it's connected to the item above)

@agent-of-mkmeral
Copy link

📚 Research: Agent-as-Tool Context Management — How Other SDKs Handle It

Cross-Framework Analysis

I went through the actual source code of Google ADK, OpenAI Agents SDK, CrewAI, and LangGraph to understand how they handle context/message history in agent-as-tool patterns.

Summary Table

Framework Agent-as-Tool Default How Isolation Works Modifies Agent?
Google ADK Fresh New Runner() + InMemorySessionService() per call ❌ Agent untouched
OpenAI Agents Fresh New Runner.run() per call ❌ Agent untouched
CrewAI Fresh New Task() per delegation ❌ Agent untouched
LangGraph Fresh Separate StateGraph state ❌ Agent untouched
Strands (this PR) Fresh deepcopy + direct assign to agent.messages Mutates agent

Key Finding: Strands Is Unique in HOW It Achieves Isolation

Every other SDK achieves isolation by creating fresh infrastructure around the agent (new runner, new session, new task). Strands achieves it by mutating the agent directly (agent.messages = deepcopy(...)).

This is why preserve_context=False has the session manager divergence bug — the reset bypasses the agent's own state management components.

What Other SDKs Do

  • Google ADK (AgentTool): Creates InMemorySessionService() and a brand new session for every invocation. State deltas forwarded back via tool_context.state.update(). Transfer/handoff pattern shares context (different mechanism).

  • OpenAI Agents SDK (agent.as_tool()): Creates a fresh Runner.run() call. Docstring: "In handoffs, the new agent receives the conversation history. In this tool, the new agent receives generated input." Supports explicit session/conversation_id params for opt-in persistence.

  • CrewAI (DelegateWorkTool): Creates a new Task() per delegation. Context is an explicit string parameter, never accumulated conversation history.

  • LangGraph: Each subgraph has its own StateGraph with independent messages state. No shared state between parent/child unless explicitly mapped.

The Tension

All SDKs agree: agent-as-tool should default to fresh/isolated context.

But Strands has a unique challenge: when developers construct an Agent with specific components (session managers, conversation managers) and pass it as a tool, preserve_context=False bypasses those components entirely. No other SDK has this problem because they don't mutate the agent.

Recommendation

Three options discussed in the detailed analysis:

  1. Make preserve_context required (no default) — forces explicit choice, collects usage data
  2. Default to True — least destructive, respects agent components, works with AgentBase
  3. Default to False (current) — industry-consistent, but broken with session managers

Long-term: the isolation mechanism should follow the ADK/OpenAI pattern (fresh execution context around the agent) rather than mutating the agent directly.


AI agent response. Strands Agents. Feedback welcome!

"AgentBase",
"AgentResult",
"AgentAsTool",
"_AgentAsTool",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we shouldn't export this


Returns:
An AgentAsTool wrapping this agent.
An _AgentAsTool wrapping this agent.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: For customers this does not mean anything. Instead, you can say "a tool wrapping the agent". At the end of the day _AgentAsToolis an AgentTool

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I am even thinking if this should return an agent tool. Because _AgentAsTool is not public, and this is a public API. cc @zastrowm wdyt?

The biggest downside of that would be, we cannot go back to AgentAsTool if/when we make it public, that'd be technically backwards incompatible change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I am even thinking if this should return an agent tool.

Agree; this should return AgentTool

The biggest downside of that would be, we cannot go back to AgentAsTool

I think we can - because it'd be widening an output type and not an input - it's still assignable to AgentTool but it's more specific so all type checks should still work. Only small concern would be that if you overrode Agent.to_tool, but I don't think that's a big enough problem to block on.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

made the copy update in the docstring

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If its backwards incompatible then we should leave it as _AgentAsTool IMO

@@ -327,25 +327,25 @@ def tool_use_id(self) -> str:
class AgentAsToolStreamEvent(ToolStreamEvent):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need a special stream event?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it is used to differentiate in what context the agent is being used for the customer: either as an agent directly or as a tool. Thread from before: #1932 (comment)

__all__ = [
"Agent",
"AgentAsTool",
"_AgentAsTool",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, we are exporting private class. we should not do it

@mkmeral
Copy link
Contributor

mkmeral commented Mar 25, 2026

/strands review


__all__ = [
"Agent",
"_AgentAsTool",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Private class exported in __all__.

The underscore prefix (_AgentAsTool) indicates this is a private/internal class, but exporting it in __all__ makes it part of the public API. This is contradictory.

Suggestion: Either:

  1. Remove _AgentAsTool from __all__ (keep it truly private), OR
  2. Rename to AgentAsTool (make it public)

Per @mkmeral's feedback, if this is intended to be private, it shouldn't be exported.

interactions with the agent. Defaults to False.

Returns:
An _AgentAsTool wrapping this agent.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Return type references private class name.

"An _AgentAsTool wrapping this agent" is meaningless to customers since _AgentAsTool is a private class they shouldn't interact with directly.

Suggestion: Change to:

Returns:
    A tool wrapping this agent that can be passed to another agent's tools list.

Or since _AgentAsTool extends AgentTool:

Returns:
    An AgentTool wrapping this agent.

"Agent",
"AgentBase",
"AgentResult",
"_AgentAsTool",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Same as above - private class exported in __all__.

Remove _AgentAsTool from __all__ to keep it truly private.

@github-actions
Copy link

Code Review Summary - Round 5

Assessment: Request Changes

The session manager divergence bug has been addressed with a ValueError check. However, there are still inconsistencies in the API design that should be resolved.

Blocking Issues
Issue Location Action Needed
Private class export strands/__init__.py:15, strands/agent/__init__.py:28 Remove _AgentAsTool from __all__ — underscore prefix means private
Docstring references private class agent.py:636 Change "An _AgentAsTool" to "A tool wrapping this agent"
What's Been Addressed
  • Session manager check (lines 89-95): ValueError raised when preserve_context=False with session manager
  • Description handling: Moved to constructor with proper fallback (line 75-77)
  • Type annotation: agent: Agent is explicit about requirements
Design Decision Needed

@mkmeral raised a valid point: should _AgentAsTool be:

  1. Truly private — Remove from __all__, customers only use agent.as_tool()
  2. Public — Rename to AgentAsTool, export properly

The current state (private name + public export) is contradictory. I'd suggest option 1 (truly private) since customers have agent.as_tool() as the main API.

Once the export inconsistency is resolved, this is ready to merge.

@notowen333
Copy link
Author

/strands review

@github-actions
Copy link

Code Review Summary - Round 6

Assessment: Approve ✅

All blocking issues from previous reviews have been resolved.

Issues Resolved
Issue Resolution
Private class export _AgentAsTool removed from __all__ in both modules
Return type ✅ Changed from _AgentAsTool to AgentTool
Docstring ✅ Now says "A tool wrapping this agent"
Final Implementation Summary

API Design:

  • _AgentAsTool is truly private (underscore prefix, not exported)
  • Public API as_tool() returns AgentTool (public base type)
  • Docstrings don't reference internal implementation details

Key Features:

  • preserve_context parameter (default False for stateless invocations)
  • Session manager check raises ValueError when incompatible
  • Concurrency protection with threading.Lock
  • Interrupt propagation to parent agent
  • AgentAsToolStreamEvent for differentiating sub-agent events

Test Coverage: 97.20% with 676 lines of comprehensive tests

The implementation is well-designed, follows SDK patterns, and addresses the original issue #1002 completely. Ready to merge! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Agent to_tool method

5 participants